@@ -7,7 +7,10 @@ APP_SECRET_TOKEN=REPLACE_ME_NOW! |
||
7 | 7 |
# for development, but it needs to be changed when you deploy to a production environment. |
8 | 8 |
DOMAIN=localhost:3000 |
9 | 9 |
|
10 |
-# Database Setup |
|
10 |
+############################ |
|
11 |
+# Database Setup # |
|
12 |
+############################ |
|
13 |
+ |
|
11 | 14 |
DATABASE_ADAPTER=mysql2 |
12 | 15 |
DATABASE_ENCODING=utf8 |
13 | 16 |
DATABASE_RECONNECT=true |
@@ -24,6 +27,10 @@ DATABASE_PASSWORD="" |
||
24 | 27 |
# Configure Rails environment. This should only be needed in production and may cause errors in development. |
25 | 28 |
# RAILS_ENV=production |
26 | 29 |
|
30 |
+############################# |
|
31 |
+# Email Configuration # |
|
32 |
+############################# |
|
33 |
+ |
|
27 | 34 |
# Outgoing email settings. To use Gmail or Google Apps, put your Google Apps domain or gmail.com |
28 | 35 |
# as the SMTP_DOMAIN and your Gmail username and password as the SMTP_USER_NAME and SMTP_PASSWORD. |
29 | 36 |
SMTP_DOMAIN=your-domain-here.com |
@@ -37,9 +44,28 @@ SMTP_ENABLE_STARTTLS_AUTO=true |
||
37 | 44 |
# The address from which system emails will appear to be sent. |
38 | 45 |
EMAIL_FROM_ADDRESS=from_address@gmail.com |
39 | 46 |
|
47 |
+############################ |
|
48 |
+# Allowing Signups # |
|
49 |
+############################ |
|
50 |
+ |
|
40 | 51 |
# This invitation code will be required for users to signup with your Huginn installation. |
41 | 52 |
# You can see its use in user.rb. |
42 | 53 |
INVITATION_CODE=try-huginn |
43 | 54 |
|
55 |
+########################### |
|
56 |
+# Agent Logging # |
|
57 |
+########################### |
|
58 |
+ |
|
44 | 59 |
# Number of lines of log messages to keep per Agent |
45 |
-AGENT_LOG_LENGTH=100 |
|
60 |
+AGENT_LOG_LENGTH=200 |
|
61 |
+ |
|
62 |
+############################# |
|
63 |
+# AWS and Mechanical Turk # |
|
64 |
+############################# |
|
65 |
+ |
|
66 |
+# AWS Credentials for MTurk |
|
67 |
+AWS_ACCESS_KEY_ID="your aws access key id" |
|
68 |
+AWS_ACCESS_KEY="your aws access key" |
|
69 |
+ |
|
70 |
+# Set AWS_SANDBOX to true if you're developing Huginn code. |
|
71 |
+AWS_SANDBOX=false |
@@ -32,6 +32,7 @@ gem 'kramdown' |
||
32 | 32 |
gem "typhoeus" |
33 | 33 |
gem 'nokogiri' |
34 | 34 |
gem 'wunderground' |
35 |
+gem 'rturk' |
|
35 | 36 |
|
36 | 37 |
gem "twitter" |
37 | 38 |
gem 'twitter-stream', '>=0.1.16' |
@@ -74,6 +74,8 @@ GEM |
||
74 | 74 |
http_parser.rb (>= 0.5.3) |
75 | 75 |
em-socksify (0.3.0) |
76 | 76 |
eventmachine (>= 1.0.0.beta.4) |
77 |
+ erector (0.9.0) |
|
78 |
+ treetop (>= 1.2.3) |
|
77 | 79 |
erubis (2.7.0) |
78 | 80 |
ethon (0.5.12) |
79 | 81 |
ffi (>= 1.3.0) |
@@ -118,7 +120,7 @@ GEM |
||
118 | 120 |
mime-types (~> 1.16) |
119 | 121 |
treetop (~> 1.4.8) |
120 | 122 |
method_source (0.8.1) |
121 |
- mime-types (1.23) |
|
123 |
+ mime-types (1.24) |
|
122 | 124 |
mini_portile (0.5.1) |
123 | 125 |
multi_json (1.7.9) |
124 | 126 |
multi_xml (0.5.5) |
@@ -182,6 +184,10 @@ GEM |
||
182 | 184 |
rspec-core (~> 2.14.0) |
183 | 185 |
rspec-expectations (~> 2.14.0) |
184 | 186 |
rspec-mocks (~> 2.14.0) |
187 |
+ rturk (2.11.0) |
|
188 |
+ erector |
|
189 |
+ nokogiri |
|
190 |
+ rest-client |
|
185 | 191 |
rufus-scheduler (2.0.22) |
186 | 192 |
tzinfo (>= 0.3.23) |
187 | 193 |
safe_yaml (0.9.5) |
@@ -205,7 +211,7 @@ GEM |
||
205 | 211 |
system_timer (1.2.4) |
206 | 212 |
thor (0.18.1) |
207 | 213 |
tilt (1.4.1) |
208 |
- treetop (1.4.14) |
|
214 |
+ treetop (1.4.15) |
|
209 | 215 |
polyglot |
210 | 216 |
polyglot (>= 0.3.1) |
211 | 217 |
twilio-ruby (3.10.0) |
@@ -269,6 +275,7 @@ DEPENDENCIES |
||
269 | 275 |
rr |
270 | 276 |
rspec |
271 | 277 |
rspec-rails |
278 |
+ rturk |
|
272 | 279 |
rufus-scheduler |
273 | 280 |
sass-rails (~> 3.2.3) |
274 | 281 |
select2-rails |
@@ -8,7 +8,7 @@ $ -> |
||
8 | 8 |
|
9 | 9 |
if json.pending? && json.pending > 0 |
10 | 10 |
tooltipOptions = { |
11 |
- title: "#{json.pending} pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures" |
|
11 |
+ title: "#{json.pending} jobs pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures" |
|
12 | 12 |
delay: 0 |
13 | 13 |
placement: "bottom" |
14 | 14 |
trigger: "hover" |
@@ -147,6 +147,7 @@ class Agent < ActiveRecord::Base |
||
147 | 147 |
end |
148 | 148 |
|
149 | 149 |
def log(message, options = {}) |
150 |
+ puts "Agent##{id}: #{message}" unless Rails.env.test? |
|
150 | 151 |
AgentLog.log_for_agent(self, message, options) |
151 | 152 |
end |
152 | 153 |
|
@@ -66,16 +66,10 @@ module Agents |
||
66 | 66 |
!recent_error_logs? |
67 | 67 |
end |
68 | 68 |
|
69 |
- def value_constructor(value, payload) |
|
70 |
- value.gsub(/<[^>]+>/).each { |jsonpath| |
|
71 |
- Utils.values_at(payload, jsonpath[1..-2]).first.to_s |
|
72 |
- } |
|
73 |
- end |
|
74 |
- |
|
75 | 69 |
def receive(incoming_events) |
76 | 70 |
incoming_events.each do |event| |
77 | 71 |
formatted_event = options[:mode].to_s == "merge" ? event.payload : {} |
78 |
- options[:instructions].each_pair {|key, value| formatted_event[key] = value_constructor value, event.payload } |
|
72 |
+ options[:instructions].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, event.payload) } |
|
79 | 73 |
formatted_event[:agent] = Agent.find(event.agent_id).type.slice!(8..-1) unless options[:skip_agent].to_s == "true" |
80 | 74 |
formatted_event[:created_at] = event.created_at unless options[:skip_created_at].to_s == "true" |
81 | 75 |
create_event :payload => formatted_event |
@@ -0,0 +1,285 @@ |
||
1 |
+require 'rturk' |
|
2 |
+ |
|
3 |
+module Agents |
|
4 |
+ class HumanTaskAgent < Agent |
|
5 |
+ default_schedule "every_10m" |
|
6 |
+ |
|
7 |
+ description <<-MD |
|
8 |
+ You can use a HumanTaskAgent to create Human Intelligence Tasks (HITs) on Mechanical Turk. |
|
9 |
+ |
|
10 |
+ HITs can be created in response to events, or on a schedule. Set `trigger_on` to either `schedule` or `event`. |
|
11 |
+ |
|
12 |
+ The schedule of this Agent is how often it should check for completed HITs, __NOT__ how often to submit one. To configure how often a new HIT |
|
13 |
+ should be submitted when in `schedule` mode, set `submission_period` to a number of hours. |
|
14 |
+ |
|
15 |
+ If created with an event, all HIT fields can contain interpolated values via [JSONPaths](http://goessner.net/articles/JsonPath/) placed between < and > characters. |
|
16 |
+ For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this: |
|
17 |
+ |
|
18 |
+ { |
|
19 |
+ "expected_receive_period_in_days": 2, |
|
20 |
+ "trigger_on": "event", |
|
21 |
+ "hit": { |
|
22 |
+ "max_assignments": 1, |
|
23 |
+ "title": "Sentiment evaluation", |
|
24 |
+ "description": "Please rate the sentiment of this message: '<$.message>'", |
|
25 |
+ "reward": 0.05, |
|
26 |
+ "questions": [ |
|
27 |
+ { |
|
28 |
+ "type": "selection", |
|
29 |
+ "key": "sentiment", |
|
30 |
+ "name": "Sentiment", |
|
31 |
+ "required": "true", |
|
32 |
+ "question": "Please select the best sentiment value:", |
|
33 |
+ "selections": [ |
|
34 |
+ { "key": "happy", "text": "Happy" }, |
|
35 |
+ { "key": "sad", "text": "Sad" }, |
|
36 |
+ { "key": "neutral", "text": "Neutral" } |
|
37 |
+ ] |
|
38 |
+ }, |
|
39 |
+ { |
|
40 |
+ "type": "free_text", |
|
41 |
+ "key": "feedback", |
|
42 |
+ "name": "Have any feedback for us?", |
|
43 |
+ "required": "false", |
|
44 |
+ "question": "Feedback", |
|
45 |
+ "default": "Type here...", |
|
46 |
+ "min_length": "2", |
|
47 |
+ "max_length": "2000" |
|
48 |
+ } |
|
49 |
+ ] |
|
50 |
+ } |
|
51 |
+ } |
|
52 |
+ |
|
53 |
+ As you can see, you configure the created HIT with the `hit` option. Required fields are `title`, which is the |
|
54 |
+ title of the created HIT, `description`, which is the description of the HIT, and `questions` which is an array of |
|
55 |
+ questions. Questions can be of `type` _selection_ or _free\\_text_. Both types require the `key`, `name`, `required`, |
|
56 |
+ `type`, and `question` configuration options. Additionally, _selection_ requires a `selections` array of options, each of |
|
57 |
+ which contain `key` and `text`. For _free\\_text_, the special configuration options are all optional, and are |
|
58 |
+ `default`, `min_length`, and `max_length`. |
|
59 |
+ |
|
60 |
+ If all of the `questions` are of `type` _selection_, you can set `take_majority` to _true_ at the top level to |
|
61 |
+ automatically select the majority vote for each question across all `max_assignments`. |
|
62 |
+ |
|
63 |
+ As with most Agents, `expected_receive_period_in_days` is required if `trigger_on` is set to `event`. |
|
64 |
+ MD |
|
65 |
+ |
|
66 |
+ event_description <<-MD |
|
67 |
+ Events look like: |
|
68 |
+ |
|
69 |
+ { |
|
70 |
+ } |
|
71 |
+ MD |
|
72 |
+ |
|
73 |
+ def validate_options |
|
74 |
+ errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options[:trigger_on]) |
|
75 |
+ |
|
76 |
+ if options[:trigger_on] == "event" |
|
77 |
+ errors.add(:base, "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'") unless options[:expected_receive_period_in_days].present? |
|
78 |
+ elsif options[:trigger_on] == "schedule" |
|
79 |
+ errors.add(:base, "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'") unless options[:submission_period].present? && options[:submission_period].to_i > 0 |
|
80 |
+ end |
|
81 |
+ |
|
82 |
+ if options[:take_majority] == "true" && options[:hit][:questions].any? { |question| question[:type] != "selection" } |
|
83 |
+ errors.add(:base, "all questions must be of type 'selection' to use the 'take_majority' option") |
|
84 |
+ end |
|
85 |
+ end |
|
86 |
+ |
|
87 |
+ def default_options |
|
88 |
+ { |
|
89 |
+ :expected_receive_period_in_days => 2, |
|
90 |
+ :trigger_on => "event", |
|
91 |
+ :hit => |
|
92 |
+ { |
|
93 |
+ :max_assignments => 1, |
|
94 |
+ :title => "Sentiment evaluation", |
|
95 |
+ :description => "Please rate the sentiment of this message: '<$.message>'", |
|
96 |
+ :reward => 0.05, |
|
97 |
+ :questions => |
|
98 |
+ [ |
|
99 |
+ { |
|
100 |
+ :type => "selection", |
|
101 |
+ :key => "sentiment", |
|
102 |
+ :name => "Sentiment", |
|
103 |
+ :required => "true", |
|
104 |
+ :question => "Please select the best sentiment value:", |
|
105 |
+ :selections => |
|
106 |
+ [ |
|
107 |
+ { :key => "happy", :text => "Happy" }, |
|
108 |
+ { :key => "sad", :text => "Sad" }, |
|
109 |
+ { :key => "neutral", :text => "Neutral" } |
|
110 |
+ ] |
|
111 |
+ }, |
|
112 |
+ { |
|
113 |
+ :type => "free_text", |
|
114 |
+ :key => "feedback", |
|
115 |
+ :name => "Have any feedback for us?", |
|
116 |
+ :required => "false", |
|
117 |
+ :question => "Feedback", |
|
118 |
+ :default => "Type here...", |
|
119 |
+ :min_length => "2", |
|
120 |
+ :max_length => "2000" |
|
121 |
+ } |
|
122 |
+ ] |
|
123 |
+ } |
|
124 |
+ } |
|
125 |
+ end |
|
126 |
+ |
|
127 |
+ def working? |
|
128 |
+ last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs? |
|
129 |
+ end |
|
130 |
+ |
|
131 |
+ def check |
|
132 |
+ setup! |
|
133 |
+ review_hits |
|
134 |
+ |
|
135 |
+ if options[:trigger_on] == "schedule" && (memory[:last_schedule] || 0) <= Time.now.to_i - options[:submission_period].to_i * 60 * 60 |
|
136 |
+ memory[:last_schedule] = Time.now.to_i |
|
137 |
+ create_hit |
|
138 |
+ end |
|
139 |
+ end |
|
140 |
+ |
|
141 |
+ def receive(incoming_events) |
|
142 |
+ if options[:trigger_on] == "event" |
|
143 |
+ setup! |
|
144 |
+ |
|
145 |
+ incoming_events.each do |event| |
|
146 |
+ create_hit event |
|
147 |
+ end |
|
148 |
+ end |
|
149 |
+ end |
|
150 |
+ |
|
151 |
+ # To be moved either into an initilizer or a per-agent setting. |
|
152 |
+ def setup! |
|
153 |
+ RTurk::logger.level = Logger::DEBUG |
|
154 |
+ RTurk.setup(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_ACCESS_KEY'], :sandbox => ENV['AWS_SANDBOX'] == "true") unless Rails.env.test? |
|
155 |
+ end |
|
156 |
+ |
|
157 |
+ protected |
|
158 |
+ |
|
159 |
+ def review_hits |
|
160 |
+ reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids |
|
161 |
+ my_reviewed_hit_ids = reviewable_hit_ids & (memory[:hits] || {}).keys.map(&:to_s) |
|
162 |
+ log "MTurk reports the following HITs [#{reviewable_hit_ids.to_sentence}], of which I own [#{my_reviewed_hit_ids.to_sentence}]" |
|
163 |
+ my_reviewed_hit_ids.each do |hit_id| |
|
164 |
+ hit = RTurk::Hit.new(hit_id) |
|
165 |
+ assignments = hit.assignments |
|
166 |
+ |
|
167 |
+ log "Looking at HIT #{hit_id}. I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}" |
|
168 |
+ if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" } |
|
169 |
+ if options[:take_majority] == "true" |
|
170 |
+ options[:hit][:questions].each do |question| |
|
171 |
+ counts = question[:selections].inject({}) { |memo, selection| memo[selection[:key]] = 0; memo } |
|
172 |
+ assignments.each do |assignment| |
|
173 |
+ answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers) |
|
174 |
+ answer = answers[question[:key]] |
|
175 |
+ counts[answer] += 1 |
|
176 |
+ end |
|
177 |
+ end |
|
178 |
+ else |
|
179 |
+ event = create_event :payload => { :answers => assignments.map(&:answers) } |
|
180 |
+ log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => Event.find_by_id(memory[:hits][hit_id.to_sym]) |
|
181 |
+ end |
|
182 |
+ |
|
183 |
+ assignments.each(&:approve!) |
|
184 |
+ |
|
185 |
+ memory[:hits].delete(hit_id.to_sym) |
|
186 |
+ end |
|
187 |
+ end |
|
188 |
+ end |
|
189 |
+ |
|
190 |
+ def create_hit(event = nil) |
|
191 |
+ payload = event ? event.payload : {} |
|
192 |
+ title = Utils.interpolate_jsonpaths(options[:hit][:title], payload).strip |
|
193 |
+ description = Utils.interpolate_jsonpaths(options[:hit][:description], payload).strip |
|
194 |
+ questions = Utils.recursively_interpolate_jsonpaths(options[:hit][:questions], payload) |
|
195 |
+ hit = RTurk::Hit.create(:title => title) do |hit| |
|
196 |
+ hit.max_assignments = (options[:hit][:max_assignments] || 1).to_i |
|
197 |
+ hit.description = description |
|
198 |
+ hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions) |
|
199 |
+ hit.reward = (options[:hit][:reward] || 0.05).to_f |
|
200 |
+ #hit.qualifications.add :approval_rate, { :gt => 80 } |
|
201 |
+ end |
|
202 |
+ memory[:hits] ||= {} |
|
203 |
+ memory[:hits][hit.id] = event && event.id |
|
204 |
+ log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event |
|
205 |
+ end |
|
206 |
+ |
|
207 |
+ # RTurk Question Form |
|
208 |
+ |
|
209 |
+ class AgentQuestionForm < RTurk::QuestionForm |
|
210 |
+ needs :title, :description, :questions |
|
211 |
+ |
|
212 |
+ def question_form_content |
|
213 |
+ Overview do |
|
214 |
+ Title do |
|
215 |
+ text @title |
|
216 |
+ end |
|
217 |
+ Text do |
|
218 |
+ text @description |
|
219 |
+ end |
|
220 |
+ end |
|
221 |
+ |
|
222 |
+ @questions.each.with_index do |question, index| |
|
223 |
+ Question do |
|
224 |
+ QuestionIdentifier do |
|
225 |
+ text question[:key] || "question_#{index}" |
|
226 |
+ end |
|
227 |
+ DisplayName do |
|
228 |
+ text question[:name] || "Question ##{index}" |
|
229 |
+ end |
|
230 |
+ IsRequired do |
|
231 |
+ text question[:required] || 'true' |
|
232 |
+ end |
|
233 |
+ QuestionContent do |
|
234 |
+ Text do |
|
235 |
+ text question[:question] |
|
236 |
+ end |
|
237 |
+ end |
|
238 |
+ AnswerSpecification do |
|
239 |
+ if question[:type] == "selection" |
|
240 |
+ |
|
241 |
+ SelectionAnswer do |
|
242 |
+ StyleSuggestion do |
|
243 |
+ text 'radiobutton' |
|
244 |
+ end |
|
245 |
+ Selections do |
|
246 |
+ question[:selections].each do |selection| |
|
247 |
+ Selection do |
|
248 |
+ SelectionIdentifier do |
|
249 |
+ text selection[:key] |
|
250 |
+ end |
|
251 |
+ Text do |
|
252 |
+ text selection[:text] |
|
253 |
+ end |
|
254 |
+ end |
|
255 |
+ end |
|
256 |
+ end |
|
257 |
+ end |
|
258 |
+ |
|
259 |
+ else |
|
260 |
+ |
|
261 |
+ FreeTextAnswer do |
|
262 |
+ if question[:min_length].present? || question[:max_length].present? |
|
263 |
+ Constraints do |
|
264 |
+ lengths = {} |
|
265 |
+ lengths[:minLength] = question[:min_length].to_s if question[:min_length].present? |
|
266 |
+ lengths[:maxLength] = question[:max_length].to_s if question[:max_length].present? |
|
267 |
+ Length lengths |
|
268 |
+ end |
|
269 |
+ end |
|
270 |
+ |
|
271 |
+ if question[:default].present? |
|
272 |
+ DefaultText do |
|
273 |
+ text question[:default] |
|
274 |
+ end |
|
275 |
+ end |
|
276 |
+ end |
|
277 |
+ |
|
278 |
+ end |
|
279 |
+ end |
|
280 |
+ end |
|
281 |
+ end |
|
282 |
+ end |
|
283 |
+ end |
|
284 |
+ end |
|
285 |
+end |
@@ -32,6 +32,25 @@ module Utils |
||
32 | 32 |
end |
33 | 33 |
end |
34 | 34 |
|
35 |
+ def self.interpolate_jsonpaths(value, data) |
|
36 |
+ value.gsub(/<[^>]+>/).each { |jsonpath| |
|
37 |
+ Utils.values_at(data, jsonpath[1..-2]).first.to_s |
|
38 |
+ } |
|
39 |
+ end |
|
40 |
+ |
|
41 |
+ def self.recursively_interpolate_jsonpaths(struct, data) |
|
42 |
+ case struct |
|
43 |
+ when Hash |
|
44 |
+ struct.inject({}) {|memo, (key, value)| memo[key] = recursively_interpolate_jsonpaths(value, data); memo } |
|
45 |
+ when Array |
|
46 |
+ struct.map {|elem| recursively_interpolate_jsonpaths(elem, data) } |
|
47 |
+ when String |
|
48 |
+ interpolate_jsonpaths(struct, data) |
|
49 |
+ else |
|
50 |
+ struct |
|
51 |
+ end |
|
52 |
+ end |
|
53 |
+ |
|
35 | 54 |
def self.value_at(data, path) |
36 | 55 |
values_at(data, path).first |
37 | 56 |
end |
@@ -27,6 +27,36 @@ describe Utils do |
||
27 | 27 |
end |
28 | 28 |
end |
29 | 29 |
|
30 |
+ describe "#interpolate_jsonpaths" do |
|
31 |
+ it "interpolates jsonpath expressions between matching <>'s" do |
|
32 |
+ Utils.interpolate_jsonpaths("hello <$.there.world> this <escape works>", { :there => { :world => "WORLD" }, :works => "should work" }).should == "hello WORLD this should+work" |
|
33 |
+ end |
|
34 |
+ end |
|
35 |
+ |
|
36 |
+ describe "#recursively_interpolate_jsonpaths" do |
|
37 |
+ it "interpolates all string values in a structure" do |
|
38 |
+ struct = { |
|
39 |
+ :int => 5, |
|
40 |
+ :string => "this <escape $.works>", |
|
41 |
+ :array => ["<works>", "now", "<$.there.world>"], |
|
42 |
+ :deep => { |
|
43 |
+ :string => "hello <there.world>", |
|
44 |
+ :hello => :world |
|
45 |
+ } |
|
46 |
+ } |
|
47 |
+ data = { :there => { :world => "WORLD" }, :works => "should work" } |
|
48 |
+ Utils.recursively_interpolate_jsonpaths(struct, data).should == { |
|
49 |
+ :int => 5, |
|
50 |
+ :string => "this should+work", |
|
51 |
+ :array => ["should work", "now", "WORLD"], |
|
52 |
+ :deep => { |
|
53 |
+ :string => "hello WORLD", |
|
54 |
+ :hello => :world |
|
55 |
+ } |
|
56 |
+ } |
|
57 |
+ end |
|
58 |
+ end |
|
59 |
+ |
|
30 | 60 |
describe "#value_at" do |
31 | 61 |
it "returns the value at a JSON path" do |
32 | 62 |
Utils.value_at({ :foo => { :bar => :baz }}.to_json, "foo.bar").should == "baz" |
@@ -0,0 +1,235 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+ |
|
3 |
+describe Agents::HumanTaskAgent do |
|
4 |
+ before do |
|
5 |
+ @checker = Agents::HumanTaskAgent.new(:name => "my human task agent") |
|
6 |
+ @checker.options = @checker.default_options |
|
7 |
+ @checker.user = users(:bob) |
|
8 |
+ @checker.save! |
|
9 |
+ |
|
10 |
+ @event = Event.new |
|
11 |
+ @event.agent = agents(:bob_rain_notifier_agent) |
|
12 |
+ @event.payload = { :foo => { "bar" => { :baz => "a2b" } }, |
|
13 |
+ :name => "Joe" } |
|
14 |
+ @event.id = 345 |
|
15 |
+ end |
|
16 |
+ |
|
17 |
+ describe "when 'trigger_on' is set to 'schedule'" do |
|
18 |
+ before do |
|
19 |
+ @checker.options[:trigger_on] = "schedule" |
|
20 |
+ @checker.options[:submission_period] = "2" |
|
21 |
+ @checker.options.delete(:expected_receive_period_in_days) |
|
22 |
+ end |
|
23 |
+ |
|
24 |
+ it "should check for reviewable HITs frequently" do |
|
25 |
+ mock(@checker).review_hits.twice |
|
26 |
+ mock(@checker).create_hit.once |
|
27 |
+ @checker.check |
|
28 |
+ @checker.check |
|
29 |
+ end |
|
30 |
+ |
|
31 |
+ it "should create HITs every 'submission_period' hours" do |
|
32 |
+ now = Time.now |
|
33 |
+ stub(Time).now { now } |
|
34 |
+ mock(@checker).review_hits.times(3) |
|
35 |
+ mock(@checker).create_hit.twice |
|
36 |
+ @checker.check |
|
37 |
+ now += 1 * 60 * 60 |
|
38 |
+ @checker.check |
|
39 |
+ now += 1 * 60 * 60 |
|
40 |
+ @checker.check |
|
41 |
+ end |
|
42 |
+ |
|
43 |
+ it "should ignore events" do |
|
44 |
+ mock(@checker).create_hit(anything).times(0) |
|
45 |
+ @checker.receive([events(:bob_website_agent_event)]) |
|
46 |
+ end |
|
47 |
+ end |
|
48 |
+ |
|
49 |
+ describe "when 'trigger_on' is set to 'event'" do |
|
50 |
+ it "should not create HITs during check but should check for reviewable HITs" do |
|
51 |
+ @checker.options[:submission_period] = "2" |
|
52 |
+ now = Time.now |
|
53 |
+ stub(Time).now { now } |
|
54 |
+ mock(@checker).review_hits.times(3) |
|
55 |
+ mock(@checker).create_hit.times(0) |
|
56 |
+ @checker.check |
|
57 |
+ now += 1 * 60 * 60 |
|
58 |
+ @checker.check |
|
59 |
+ now += 1 * 60 * 60 |
|
60 |
+ @checker.check |
|
61 |
+ end |
|
62 |
+ |
|
63 |
+ it "should create HITs based on events" do |
|
64 |
+ mock(@checker).create_hit(events(:bob_website_agent_event)).times(1) |
|
65 |
+ @checker.receive([events(:bob_website_agent_event)]) |
|
66 |
+ end |
|
67 |
+ end |
|
68 |
+ |
|
69 |
+ describe "creating hits" do |
|
70 |
+ it "can create HITs based on events, interpolating their values" do |
|
71 |
+ @checker.options[:hit][:title] = "Hi <.name>" |
|
72 |
+ @checker.options[:hit][:description] = "Make something for <.name>" |
|
73 |
+ @checker.options[:hit][:questions][0][:name] = "<.name> Question 1" |
|
74 |
+ |
|
75 |
+ question_form = nil |
|
76 |
+ hitInterface = OpenStruct.new |
|
77 |
+ hitInterface.id = 123 |
|
78 |
+ mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance } |
|
79 |
+ mock(RTurk::Hit).create(:title => "Hi Joe").yields(hitInterface) { hitInterface } |
|
80 |
+ |
|
81 |
+ @checker.send :create_hit, @event |
|
82 |
+ |
|
83 |
+ hitInterface.max_assignments.should == @checker.options[:hit][:max_assignments] |
|
84 |
+ hitInterface.reward.should == @checker.options[:hit][:reward] |
|
85 |
+ hitInterface.description.should == "Make something for Joe" |
|
86 |
+ |
|
87 |
+ xml = question_form.to_xml |
|
88 |
+ xml.should include("<Title>Hi Joe</Title>") |
|
89 |
+ xml.should include("<Text>Make something for Joe</Text>") |
|
90 |
+ xml.should include("<DisplayName>Joe Question 1</DisplayName>") |
|
91 |
+ |
|
92 |
+ @checker.memory[:hits][123].should == @event.id |
|
93 |
+ end |
|
94 |
+ |
|
95 |
+ it "works without an event too" do |
|
96 |
+ @checker.options[:hit][:title] = "Hi <.name>" |
|
97 |
+ hitInterface = OpenStruct.new |
|
98 |
+ hitInterface.id = 123 |
|
99 |
+ mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) |
|
100 |
+ mock(RTurk::Hit).create(:title => "Hi").yields(hitInterface) { hitInterface } |
|
101 |
+ @checker.send :create_hit |
|
102 |
+ hitInterface.max_assignments.should == @checker.options[:hit][:max_assignments] |
|
103 |
+ hitInterface.reward.should == @checker.options[:hit][:reward] |
|
104 |
+ end |
|
105 |
+ end |
|
106 |
+ |
|
107 |
+ describe "reviewing HITs" do |
|
108 |
+ class FakeHit |
|
109 |
+ def initialize(options = {}) |
|
110 |
+ @options = options |
|
111 |
+ end |
|
112 |
+ |
|
113 |
+ def assignments |
|
114 |
+ @options[:assignments] || [] |
|
115 |
+ end |
|
116 |
+ |
|
117 |
+ def max_assignments |
|
118 |
+ @options[:max_assignments] || 1 |
|
119 |
+ end |
|
120 |
+ end |
|
121 |
+ |
|
122 |
+ class FakeAssignment |
|
123 |
+ attr_accessor :approved |
|
124 |
+ |
|
125 |
+ def initialize(options = {}) |
|
126 |
+ @options = options |
|
127 |
+ end |
|
128 |
+ |
|
129 |
+ def answers |
|
130 |
+ @options[:answers] || {} |
|
131 |
+ end |
|
132 |
+ |
|
133 |
+ def status |
|
134 |
+ @options[:status] || "" |
|
135 |
+ end |
|
136 |
+ |
|
137 |
+ def approve! |
|
138 |
+ @approved = true |
|
139 |
+ end |
|
140 |
+ end |
|
141 |
+ |
|
142 |
+ it "should work on multiple HITs" do |
|
143 |
+ event2 = Event.new |
|
144 |
+ event2.agent = agents(:bob_rain_notifier_agent) |
|
145 |
+ event2.payload = { :foo2 => { "bar2" => { :baz2 => "a2b2" } }, |
|
146 |
+ :name2 => "Joe2" } |
|
147 |
+ event2.id = 3452 |
|
148 |
+ |
|
149 |
+ # It knows about two HITs from two different events. |
|
150 |
+ @checker.memory[:hits] = {} |
|
151 |
+ @checker.memory[:hits][:"JH3132836336DHG"] = @event.id |
|
152 |
+ @checker.memory[:hits][:"JH39AA63836DHG"] = event2.id |
|
153 |
+ |
|
154 |
+ hit_ids = %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] |
|
155 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { hit_ids } } # It sees 3 HITs. |
|
156 |
+ |
|
157 |
+ # It looksup the two HITs that it owns. Neither are ready yet. |
|
158 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { FakeHit.new } |
|
159 |
+ mock(RTurk::Hit).new("JH39AA63836DHG") { FakeHit.new } |
|
160 |
+ |
|
161 |
+ @checker.send :review_hits |
|
162 |
+ end |
|
163 |
+ |
|
164 |
+ it "shouldn't do anything if an assignment isn't ready" do |
|
165 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id } |
|
166 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } } |
|
167 |
+ assignments = [ |
|
168 |
+ FakeAssignment.new(:status => "Accepted", :answers => {}), |
|
169 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"}) |
|
170 |
+ ] |
|
171 |
+ hit = FakeHit.new(:max_assignments => 2, :assignments => assignments) |
|
172 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit } |
|
173 |
+ |
|
174 |
+ # One of the assignments isn't set to "Submitted", so this should get skipped for now. |
|
175 |
+ mock.any_instance_of(FakeAssignment).answers.times(0) |
|
176 |
+ |
|
177 |
+ @checker.send :review_hits |
|
178 |
+ |
|
179 |
+ assignments.all? {|a| a.approved == true }.should be_false |
|
180 |
+ @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id } |
|
181 |
+ end |
|
182 |
+ |
|
183 |
+ it "shouldn't do anything if an assignment is missing" do |
|
184 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id } |
|
185 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } } |
|
186 |
+ assignments = [ |
|
187 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"}) |
|
188 |
+ ] |
|
189 |
+ hit = FakeHit.new(:max_assignments => 2, :assignments => assignments) |
|
190 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit } |
|
191 |
+ |
|
192 |
+ # One of the assignments hasn't shown up yet, so this should get skipped for now. |
|
193 |
+ mock.any_instance_of(FakeAssignment).answers.times(0) |
|
194 |
+ |
|
195 |
+ @checker.send :review_hits |
|
196 |
+ |
|
197 |
+ assignments.all? {|a| a.approved == true }.should be_false |
|
198 |
+ @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id } |
|
199 |
+ end |
|
200 |
+ |
|
201 |
+ it "should create events when all assignments are ready" do |
|
202 |
+ @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id } |
|
203 |
+ mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } } |
|
204 |
+ assignments = [ |
|
205 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>""}), |
|
206 |
+ FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"}) |
|
207 |
+ ] |
|
208 |
+ hit = FakeHit.new(:max_assignments => 2, :assignments => assignments) |
|
209 |
+ mock(RTurk::Hit).new("JH3132836336DHG") { hit } |
|
210 |
+ |
|
211 |
+ lambda { |
|
212 |
+ @checker.send :review_hits |
|
213 |
+ }.should change { Event.count }.by(1) |
|
214 |
+ |
|
215 |
+ assignments.all? {|a| a.approved == true }.should be_true |
|
216 |
+ |
|
217 |
+ @checker.events.last.payload[:answers].should == [ |
|
218 |
+ {:sentiment => "neutral", :feedback => ""}, |
|
219 |
+ {:sentiment => "happy", :feedback => "Take 2"} |
|
220 |
+ ] |
|
221 |
+ |
|
222 |
+ @checker.memory[:hits].should == {} |
|
223 |
+ end |
|
224 |
+ |
|
225 |
+ describe "taking majority votes" do |
|
226 |
+ it "should only be valid when all questions are of type 'selection'" do |
|
227 |
+ |
|
228 |
+ end |
|
229 |
+ |
|
230 |
+ it "should take the majority votes of all questions" do |
|
231 |
+ |
|
232 |
+ end |
|
233 |
+ end |
|
234 |
+ end |
|
235 |
+end |
@@ -1,14 +1,14 @@ |
||
1 | 1 |
require 'spec_helper' |
2 | 2 |
|
3 | 3 |
describe Agents::PostAgent do |
4 |
- before do |
|
5 |
- @valid_params = { |
|
6 |
- :name => "somename", |
|
7 |
- :options => { |
|
8 |
- :post_url => "http://www.example.com", |
|
9 |
- :expected_receive_period_in_days => 1 |
|
10 |
- } |
|
11 |
- } |
|
4 |
+ before do |
|
5 |
+ @valid_params = { |
|
6 |
+ :name => "somename", |
|
7 |
+ :options => { |
|
8 |
+ :post_url => "http://www.example.com", |
|
9 |
+ :expected_receive_period_in_days => 1 |
|
10 |
+ } |
|
11 |
+ } |
|
12 | 12 |
|
13 | 13 |
@checker = Agents::PostAgent.new(@valid_params) |
14 | 14 |
@checker.user = users(:jane) |
@@ -17,55 +17,55 @@ describe Agents::PostAgent do |
||
17 | 17 |
@event = Event.new |
18 | 18 |
@event.agent = agents(:jane_weather_agent) |
19 | 19 |
@event.payload = { |
20 |
- :somekey => "somevalue", |
|
21 |
- :someotherkey => { |
|
22 |
- :somekey => "value" |
|
23 |
- } |
|
20 |
+ :somekey => "somevalue", |
|
21 |
+ :someotherkey => { |
|
22 |
+ :somekey => "value" |
|
23 |
+ } |
|
24 | 24 |
} |
25 | 25 |
|
26 | 26 |
@sent_messages = [] |
27 |
- stub.any_instance_of(Agents::PostAgent).post_event { |uri,event| @sent_messages << event} |
|
28 |
- end |
|
27 |
+ stub.any_instance_of(Agents::PostAgent).post_event { |uri, event| @sent_messages << event } |
|
28 |
+ end |
|
29 | 29 |
|
30 |
- describe "#receive" do |
|
31 |
- it "checks if it can handle multiple events" do |
|
32 |
- event1 = Event.new |
|
33 |
- event1.agent = agents(:bob_weather_agent) |
|
34 |
- event1.payload = { |
|
35 |
- :xyz => "value1", |
|
36 |
- :message => "value2" |
|
37 |
- } |
|
30 |
+ describe "#receive" do |
|
31 |
+ it "checks if it can handle multiple events" do |
|
32 |
+ event1 = Event.new |
|
33 |
+ event1.agent = agents(:bob_weather_agent) |
|
34 |
+ event1.payload = { |
|
35 |
+ :xyz => "value1", |
|
36 |
+ :message => "value2" |
|
37 |
+ } |
|
38 | 38 |
|
39 |
- lambda { |
|
40 |
- @checker.receive([@event,event1]) |
|
41 |
- }.should change { @sent_messages.length }.by(2) |
|
42 |
- end |
|
39 |
+ lambda { |
|
40 |
+ @checker.receive([@event, event1]) |
|
41 |
+ }.should change { @sent_messages.length }.by(2) |
|
43 | 42 |
end |
43 |
+ end |
|
44 | 44 |
|
45 |
- describe "#working?" do |
|
46 |
- it "checks if events have been received within expected receive period" do |
|
47 |
- @checker.should_not be_working |
|
48 |
- Agents::PostAgent.async_receive @checker.id, [@event.id] |
|
49 |
- @checker.reload.should be_working |
|
50 |
- two_days_from_now = 2.days.from_now |
|
51 |
- stub(Time).now { two_days_from_now } |
|
52 |
- @checker.reload.should_not be_working |
|
53 |
- end |
|
45 |
+ describe "#working?" do |
|
46 |
+ it "checks if events have been received within expected receive period" do |
|
47 |
+ @checker.should_not be_working |
|
48 |
+ Agents::PostAgent.async_receive @checker.id, [@event.id] |
|
49 |
+ @checker.reload.should be_working |
|
50 |
+ two_days_from_now = 2.days.from_now |
|
51 |
+ stub(Time).now { two_days_from_now } |
|
52 |
+ @checker.reload.should_not be_working |
|
54 | 53 |
end |
54 |
+ end |
|
55 | 55 |
|
56 |
- describe "validation" do |
|
57 |
- before do |
|
58 |
- @checker.should be_valid |
|
59 |
- end |
|
56 |
+ describe "validation" do |
|
57 |
+ before do |
|
58 |
+ @checker.should be_valid |
|
59 |
+ end |
|
60 | 60 |
|
61 |
- it "should validate presence of post_url" do |
|
62 |
- @checker.options[:post_url] = "" |
|
63 |
- @checker.should_not be_valid |
|
64 |
- end |
|
61 |
+ it "should validate presence of post_url" do |
|
62 |
+ @checker.options[:post_url] = "" |
|
63 |
+ @checker.should_not be_valid |
|
64 |
+ end |
|
65 | 65 |
|
66 |
- it "should validate presence of expected_receive_period_in_days" do |
|
67 |
- @checker.options[:expected_receive_period_in_days] = "" |
|
68 |
- @checker.should_not be_valid |
|
69 |
- end |
|
66 |
+ it "should validate presence of expected_receive_period_in_days" do |
|
67 |
+ @checker.options[:expected_receive_period_in_days] = "" |
|
68 |
+ @checker.should_not be_valid |
|
70 | 69 |
end |
70 |
+ end |
|
71 | 71 |
end |